Skip to content

fix(server): serialize chained async memo values resolved after a nested Loading boundary commits#2798

Open
brenelz wants to merge 1 commit into
solidjs:nextfrom
brenelz:fix/nested-loading-boundary-chained-memo-serialization
Open

fix(server): serialize chained async memo values resolved after a nested Loading boundary commits#2798
brenelz wants to merge 1 commit into
solidjs:nextfrom
brenelz:fix/nested-loading-boundary-chained-memo-serialization

Conversation

@brenelz

@brenelz brenelz commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Problem

A chained async memo reached through a synchronous derived memo drops its serialized value when it lives inside a nested <Loading> boundary. On the client the memo then re-runs its compute and orphans the server-streamed fragment:

Hydration completed with 1 unclaimed server-rendered node(s):
  <div _hk="…">item 1</div>

Minimal repro (the shape TanStack Start produces — route content nested in the root layout's boundary):

function Inner() {
  const a = createMemo(async () => { await sleep(10); return [1]; });
  const m = createMemo(() => a()[0]);            // sync — re-throws while a is pending
  const b = createMemo(() => fetchItems(m()));   // body throws synchronously on first pass
  return (
    <Loading fallback={<div>loading</div>}>
      <For each={b()}>{x => <div>{x}</div>}</For>
    </Loading>
  );
}

// BUG: only reproduces when nested in another boundary
<Loading fallback={<div>outer</div>}>
  <Inner />
</Loading>

The serialized stream contains a's [1] but not b's ["item 1"].

Root cause

b depends on a, so it resolves after a — and in the nested case that is after the surrounding boundary has already flushed and committed its serializeBuffer. b's late serialize call landed in a buffer that is never flushed again, so the value was silently dropped. (a survives because it resolves before the commit.) Single-boundary works only by timing luck.

Fix

Once a boundary has flushed, later serializations write through to the parent context instead of buffering into a buffer that will never flush again. Safe because buffer resets only happen during retry discovery, which is always before the first flush.

let flushed = false;
bufferedCtx.serialize = (id, value, deferStream) => {
  if (flushed) ctx.serialize(id, value, deferStream);
  else serializeBuffer.push([id, value, deferStream]);
};
function flushSerializeBuffer() {
  for (const args of serializeBuffer) ctx.serialize(args[0], args[1], args[2]);
  serializeBuffer = [];
  flushed = true;
}

Tests

Added to the existing describe("SSR Streaming — Chained Async") suite: single boundary, nested boundary, deeply nested boundaries, and renderToStringAsync nested. The three nested cases fail without the fix and pass with it; single passes either way. Full server (91), hydration (18), and client (298) suites pass.

🤖 Generated with Claude Code

…ted Loading boundary commits

A chained async memo reached through a synchronous derived memo resolves only
after its dependency, so inside a nested <Loading> boundary it serializes after
the surrounding boundary has already flushed/committed. The late serialization
landed in a buffer that never flushed again, dropping the value. On the client
the memo re-ran its compute and orphaned the server-streamed fragment
("unclaimed server-rendered node"). This is the shape produced when route
content is nested in a root layout's boundary (e.g. TanStack Start).

Once a boundary has flushed, later serializations now write through to the
parent context instead of buffering into a buffer that will never flush again.

Adds regression coverage to the "SSR Streaming — Chained Async" suite for
single, nested, deeply nested, and renderToStringAsync cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jun 25, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 088f97e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 9 packages
Name Type
solid-js Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
@solidjs/universal Patch
@solidjs/web Patch
test-integration Patch
babel-preset-solid Patch
@solidjs/signals Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codspeed-hq

codspeed-hq Bot commented Jun 25, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 36.58%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 2 improved benchmarks
✅ 116 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
merge 331.5 µs 222.8 µs +48.77%
omit 215.7 µs 172 µs +25.4%

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing brenelz:fix/nested-loading-boundary-chained-memo-serialization (088f97e) with next (a4ca10b)

Open in CodSpeed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant